Skip to content

Agents: a 409 that is no cancellation fails the turn; an approval denied with 'no' says so - #304

Merged
igalshilman merged 3 commits into
mainfrom
agents-409-is-not-a-cancellation
Nov 9, 2026
Merged

igalshilman merged 3 commits into
mainfrom
agents-409-is-not-a-cancellation

Conversation

@igalshilman

Copy link
Copy Markdown
Contributor

Found by exercising the agents against a real model (OpenAI) and Restate 1.8.1.

  • A 409 that is no cancellation fails the turn, rather than stopping it. A turn took any TerminalError with status 409 for Restate's cancellation, and closed as stopped: but a call fails with 409 for other reasons too. refund_graph.py's payout workflow, run a second time for an order (from another conversation), failed with 409 "the workflow method was already invoked", and the turn's answer was "Cancelled: the turn was stopped before it finished.". is_cancellation takes Restate's cancellation error only, 409 "cancelled", which a stopped turn fails with too (_turn.cancellation, the SDK's cancel signal, a stored cancelled turn). The same for a Pydantic AI tool's terminal error, which the model now sees. Three tests stood TerminalError("stopped", 409) in for a stop: they raise 409 "cancelled" now.
  • Pydantic AI: an approval denied with "no" tells the model the call was denied. Any text but an approving one denied the call with that text as the reason, so a manager's "no" reached the model as the call's result ("the tool returned no"). A text that only says no (no, denied, rejected...) denies with Pydantic AI's own "The tool call was denied."; any other text is still the reason.

Checked live: the second payout of an order now fails the turn with "Error: the workflow method was already invoked"; a stop, and Restate's cancel (the admin API), of a turn in a tool and of one waiting on a subagent (team.py), still end it cancelled, with the subagents cancelled too. A "no" to a Pydantic AI approval: the model answers that the request was denied.

Checks: ruff format --check, ruff check, pyright and mypy, and pytest tests/* (563 passed) locally; the new test of the 409 fails without the fix.

…pping it

A turn took any TerminalError with status 409 for Restate's cancellation,
and closed as stopped: but a call fails with 409 for other reasons too.
refund_graph.py's payout workflow, run a second time for an order, failed
with 409 'the workflow method was already invoked', and the turn said
'Cancelled: the turn was stopped before it finished.'. is_cancellation
takes Restate's cancellation error only: 409 'cancelled', which a stopped
turn fails with too. The same for a Pydantic AI tool's terminal error,
which the model now sees.
…ancelled'

Three tests stood a TerminalError('stopped', 409) in for a stop. A stop,
or Restate cancelling the invocation, fails with 409 'cancelled', which
is_cancellation now tells from other 409s.
…s denied

Any text but an approving one denied the call, with that text as the
reason: a manager's 'no' reached the model as the call's result, and it
answered that 'the tool returned no'. A text that only says no ('no',
'denied', 'rejected'...) denies with Pydantic AI's own message, 'The
tool call was denied.'; any other text is still the reason.

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ No new issues found.

Reviewed changes

I reviewed all 3 commits: the narrower check for what counts as a cancellation, the tests updated to match it, and the change to how Pydantic AI handles a bare denial.

  • is_cancellation replaces the bare 409 check. answering in _conversation.py and Pydantic AI's on_tool_execute_error now count an error as a stop only when it is a TerminalError with status 409 and the message "cancelled". I checked every place in the repo that produces a cancellation: _turn.cancellation(), server_context on DoProgressCancelSignalReceived, _preview.py, stored_answer and sessions/_selector.py. All of them raise exactly "cancelled", so a real stop still closes the turn as cancelled. Turns use explicit cancellation, so the stop path never depends on messages the runtime writes.
  • Other 409s now fail the turn. A workflow invoked a second time, or a child that the runtime itself cancelled or killed, now fails the turn, and Pydantic AI gives the model an "Error: ..." tool result instead of the false CANCELLED answer.
  • Tests that stood in for a stop now raise the real error. They raise TerminalError("cancelled", 409), and the new conversation test pins down how a 409 that is not a cancellation gets stored.
  • A bare denial uses Pydantic AI's own message. approval_of maps "", no, denied, rejected, declined and refused (plus their verb forms) to ToolDenied(). Any other text that does not approve is still passed on as the reason.

I ran the in-process tests that touch these paths locally (-k "409 or stopped", approvals_signal), and they pass. The tests that need the Docker harness could not run here.

Pullfrog  | View workflow run | Using claude-opus-5-5 | 𝕏

@github-actions

github-actions Bot commented Nov 9, 2026

Copy link
Copy Markdown

Test Results

  8 files  ±0    8 suites  ±0   3m 40s ⏱️ -1s
 71 tests ±0   71 ✅ ±0  0 💤 ±0  0 ❌ ±0 
322 runs  ±0  322 ✅ ±0  0 💤 ±0  0 ❌ ±0 

Results for commit 150883d. ± Comparison against base commit 56e0a86.

@igalshilman
igalshilman merged commit cdcee16 into main Nov 9, 2026
12 checks passed
@igalshilman
igalshilman deleted the agents-409-is-not-a-cancellation branch November 9, 2026 12:29
@github-actions github-actions Bot locked and limited conversation to collaborators Nov 9, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant